iT邦幫忙

2

用 JavaScript 計算圖片平均色:透明度加權與 sRGB 線性化

  • 分享至 

  • xImage
  •  

圖片平均色的計算需要先決定兩件事:透明像素佔多少權重,以及 RGB 要在哪個色彩空間取平均。只把 8-bit R、G、B 相加再除以像素數,沒有處理這兩個條件。

以下函式計算的是「以 Alpha 為權重、在線性 sRGB 中取平均後,重新編碼成 sRGB 的顏色」。每個像素的面積相同。這個統計值不代表圖片中出現次數最多的顏色,也不預測人眼認定的主色。

用兩個像素看出問題

先手動建立一個未預乘 Alpha 的 RGBA 陣列:

第一個像素:255, 0, 0, 0
第二個像素:0, 0, 255, 255

第一個像素完全透明,第二個像素是不透明的藍色。若直接平均 RGB,會得到接近紫色的數值,讓完全透明像素的紅色參與了結果。

這是用來測試演算法的合成資料,不表示 Canvas 一定會保留透明像素原本的 RGB。Canvas 的 Alpha 表示方式轉換可能損失資訊,不能依賴完全透明處的顏色被原樣讀回。

另一個例子是不透明的黑色與白色。直接平均 8-bit 數值會得到 127.5,四捨五入為 128。若先轉成線性 sRGB,兩者的通道值是 0 與 1,平均後為 0.5;重新編碼回 8-bit sRGB,得到的是 188。

128 與 188 對應不同的平均方式。先選定統計的定義,再用一致的輸入限制與預期結果測試函式。

先在線性空間處理,再乘上 Alpha

sRGB 的 8-bit 通道值不是線性的光強度數值。以通道 c 為例,先計算 s = c / 255,再套用分段轉換:

s <= 0.04045:linear = s / 12.92
其他情況:linear = ((s + 0.055) / 1.055) ^ 2.4

接著將每個像素的 Alpha 正規化為 a = A / 255,計算三個通道的加權總和。以紅色通道為例:

平均線性紅色 = Σ(a × linearR) / Σa
平均 Alpha = Σa / 像素數

RGB 的分母使用 Alpha 總和,因此完全透明的像素不會改變平均 RGB。平均 Alpha 的分母仍然是全部像素數,透明區域會影響這個數值。若所有像素的 Alpha 都是 0,就沒有可回傳的代表色,函式回傳 null。

這裡的加權乘法是在統計時進行,不是要求把輸入的 RGB 預先乘過 Alpha。若把已預乘的資料送進同一個函式,會再次套用權重,得到不同的計算。

完整函式

輸入限定為 8-bit、sRGB、未預乘 Alpha 的 RGBA 資料,型別為 Uint8ClampedArray。其他色域或浮點格式需要先處理,不能只換一個陣列型別就沿用。

將 256 個 sRGB 通道值先轉成查找表,迴圈內就只需查表和累加。結果最後才重新編碼與取整數,避免每個像素先取整數後再累加。

const SRGB_TO_LINEAR = Float64Array.from({ length: 256 }, (_, value) => {
  const s = value / 255;
  return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});

function linearToSrgbByte(value) {
  const l = Math.max(0, Math.min(1, value));
  const s = l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055;
  return Math.round(s * 255);
}

function averageVisibleSrgb8(data) {
  if (!(data instanceof Uint8ClampedArray)) {
    throw new TypeError('Expected an rgba-unorm8 Uint8ClampedArray');
  }
  if (data.length === 0 || data.length % 4 !== 0) {
    throw new RangeError('Expected one or more complete RGBA pixels');
  }

  let red = 0;
  let green = 0;
  let blue = 0;
  let alphaSum = 0;

  for (let i = 0; i < data.length; i += 4) {
    const alpha = data[i + 3] / 255;
    if (alpha === 0) continue;
    red += SRGB_TO_LINEAR[data[i]] * alpha;
    green += SRGB_TO_LINEAR[data[i + 1]] * alpha;
    blue += SRGB_TO_LINEAR[data[i + 2]] * alpha;
    alphaSum += alpha;
  }

  if (alphaSum === 0) return null;

  return {
    rgb: [red, green, blue].map(sum => linearToSrgbByte(sum / alphaSum)),
    meanAlpha: alphaSum / (data.length / 4),
  };
}

用小資料測試結果

將函式放進 JavaScript 執行環境後,可以先用合成像素測試。這些資料的預期結果容易直接核對,也能分辨是透明度權重或色彩轉換出錯。

const transparentRedAndBlue = new Uint8ClampedArray([
  255, 0, 0, 0,
  0, 0, 255, 255,
]);
console.log(averageVisibleSrgb8(transparentRedAndBlue));
// { rgb: [0, 0, 255], meanAlpha: 0.5 }

const blackAndWhite = new Uint8ClampedArray([
  0, 0, 0, 255,
  255, 255, 255, 255,
]);
console.log(averageVisibleSrgb8(blackAndWhite));
// { rgb: [188, 188, 188], meanAlpha: 1 }

另加一組全透明資料,確認結果為 null;再把不透明藍色與 Alpha 為 128 的紅色放在一起,RGB 應為 [156, 0, 213]。這組資料可以檢查程式是否把所有非零 Alpha 都誤當成相同權重。

這個版本的純函式已在 Node.js 22.22.0 跑過測試,包括上面的案例、涵蓋各通道 0 至 255 的單像素案例、資料重複與重新排列,以及錯誤的輸入格式。這些測試驗證數值運算;瀏覽器解碼、跨來源權限和實際圖片的色彩轉換,仍是另外的整合範圍。

接到 Canvas 的讀取流程

以下片段假設 canvas 已畫入完成解碼、且允許讀取像素的圖片,寬高也都大於 0:

const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height, {
  colorSpace: 'srgb',
  pixelFormat: 'rgba-unorm8',
});

if (imageData.colorSpace !== 'srgb') {
  throw new TypeError('Expected sRGB image data');
}

const result = averageVisibleSrgb8(imageData.data);

這段讀取方式需要瀏覽器支援對應的設定。若輸入是 Display P3 或浮點 HDR 資料,不能假裝它已符合 sRGB 8-bit 的輸入條件。跨來源圖片若使 Canvas 不再允許讀回像素,getImageData() 會拋出安全性例外,平均色函式也不會因此取得讀取權限。

函式會掃描全部像素,時間複雜度是 O(N),累加本身只需固定大小的額外空間。取得完整 ImageData 仍需要一份像素緩衝區;若圖片尺寸很大,要另外評估讀回與運算的成本。先縮圖再統計會改變輸入資料,不能把那個結果當作已經驗證過的全像素結果。

要顯示在指定背景上時

這個函式沒有選擇背景色。若需求是「合成到白底以後的平均色」,就應在指定的色彩空間中先完成合成,再對合成後的像素計算;用可見像素的平均 RGB 直接代替,會把透明區域的背景貢獻漏掉。

同樣地,圖片主色可能需要色彩分群,不能從單一平均值判定。保存結果時,一併記錄色彩空間、Alpha 權重和背景合成條件。比較兩次結果前,先核對這些設定是否相同。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言